{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Lab 1: 肥胖风险类型预测\n",
    "\n",
    "## 数据描述\n",
    "\n",
    "在这个实验中，我们将使用深度学习来预测肥胖风险类型，数据集 `ORTP_2000.csv` 包含了 2000 个人的特征，我们将使用这些特征来预测个体的肥胖风险类型。以下是数据集中相关变量的描述：\n",
    "\n",
    "- Gender: 性别（男/女）  \n",
    "- Age: 年龄（岁）  \n",
    "- Height: 身高（米）  \n",
    "- Weight: 体重（千克）  \n",
    "- family_history_with_overweight: 是否有超重家族史（是/否）  \n",
    "- FAVC: 是否经常食用高热量食物（是/否）  \n",
    "- FCVC: 蔬菜摄入频率（1 到 3 级）  \n",
    "- NCP: 每日主餐次数  \n",
    "- CAEC: 进食零食的频率（有时、经常、总是、无）  \n",
    "- SMOKE: 是否吸烟（是/否）  \n",
    "- CH2O: 每日饮水量（升）  \n",
    "- SCC: 是否监测卡路里摄入（是/否）  \n",
    "- FAF: 体育活动频率（0 到 3 级）  \n",
    "- TUE: 使用电子设备的时间（0 到 2 级）  \n",
    "- CALC: 饮酒频率（有时、经常、总是、无）  \n",
    "- MTRANS: 主要出行方式（公共交通、汽车、步行等）  \n",
    "- NObeyesdad: 目标变量：肥胖分类（如正常体重、I 型肥胖、II 级超重等）  \n",
    "\n",
    "## 评估指标\n",
    "\n",
    "分类准确率，即分类正确的样本数占总样本数的比例：\n",
    "\n",
    "$$\n",
    "\\text{Accuracy} = \\frac{\\text{Number of correct predictions}}{\\text{Total number of predictions}}\n",
    "$$\n",
    "\n",
    "## 要求\n",
    "\n",
    "50个epoch内，在训练集上的准确率大于90%, 在测试集上的准确率大于85%\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 数据预处理\n",
    "\n",
    "我们先读取并观察数据的格式\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "\n",
    "df = pd.read_csv(\"ORTP_2000.csv\")\n",
    "df"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "可以看到，我们的数据集中有 2000 个样本，每个样本有 16 个特征和 1 个标签（NObeyesdad）。\n",
    "\n",
    "其中特征有两种类型：\n",
    "\n",
    "- 一种是类别型，对于有序类别（如 \"低、中、高\"），常用OrdinalEncoder编码；\n",
    "对于无序类别（如 \"Red, Blue, Green\"），常用OneHotEncoder编码；\n",
    "- 一种是数值型，例如 Age、Height、Weight 等，我们将其归一化，使得均值为 0，方差为 1.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.preprocessing import LabelEncoder, StandardScaler, OneHotEncoder, OrdinalEncoder\n",
    "import numpy as np\n",
    "\n",
    "def preprocess_data(df):\n",
    "    \"\"\"\n",
    "    1. 对给定的 DataFrame 进行预处理，提取出 features 和 labels.\n",
    "    2. 将数值型的 features 进行标准化，将类别型的 features 和 labels 转换为相应编码\n",
    "\n",
    "    Args:\n",
    "        df (pandas.DataFrame): The input DataFrame containing the data.\n",
    "\n",
    "    Returns:\n",
    "        - features (numpy.Array): The preprocessed features.\n",
    "        - labels (numpy.Array): The preprocessed labels.\n",
    "    \"\"\"\n",
    "    # 区分特征（无序类别、有序类别、数值型）和标签（有序类别）\n",
    "    nominal_features = [\"Gender\", \"family_history_with_overweight\", \"SMOKE\", \"SCC\", \"MTRANS\"]\n",
    "    ordinal_features = [\"FAVC\", \"CAEC\", \"CALC\"]\n",
    "    numerical_features = [\"Age\", \"Height\", \"Weight\", \"FCVC\", \"NCP\", \"CH2O\", \"FAF\", \"TUE\"]\n",
    "    label_column = \"NObeyesdad\"\n",
    "    \n",
    "    # 利用sklearn中的OrdinalEncoder对特征（有序类别）和标签（有序类别）编码\n",
    "    # 用OneHotEncoder对特征（无序类别）编码\n",
    "    # 用StandardScaler对特征（数值型）标准化\n",
    "    df[ordinal_features] = df[ordinal_features].apply(lambda x: x.str.capitalize())\n",
    "    ordinal_mappings = {\n",
    "        \"FAVC\": [[\"No\", \"Yes\"]],\n",
    "        \"CAEC\": [[\"No\", \"Sometimes\", \"Frequently\", \"Always\"]],\n",
    "        \"CALC\": [[\"No\", \"Sometimes\", \"Frequently\"]]\n",
    "    }\n",
    "    obesity_levels = [\"Insufficient_Weight\", \"Normal_Weight\", \"Overweight_Level_I\", \"Overweight_Level_II\", \n",
    "                    \"Obesity_Type_I\", \"Obesity_Type_II\", \"Obesity_Type_III\"]\n",
    "\n",
    "    ordinal_encoder = OrdinalEncoder(categories=[ordinal_mappings[feature][0] for feature in ordinal_features], handle_unknown='use_encoded_value', unknown_value=-1)\n",
    "    df[ordinal_features] = ordinal_encoder.fit_transform(df[ordinal_features])\n",
    "\n",
    "    encoder = OneHotEncoder(drop='first', sparse_output=False)\n",
    "    categorical_encoded = encoder.fit_transform(df[nominal_features])\n",
    "\n",
    "    label_encoder = OrdinalEncoder(categories=[obesity_levels])\n",
    "    df[label_column] = label_encoder.fit_transform(df[[label_column]])\n",
    "\n",
    "    scaler = StandardScaler()\n",
    "    numerical_scaled = scaler.fit_transform(df[numerical_features])\n",
    "\n",
    "    # 得到特征、标签（array形式）\n",
    "    features = np.hstack((numerical_scaled, categorical_encoded, df[ordinal_features].values))\n",
    "    labels = df[label_column].values.flatten()\n",
    "\n",
    "    return features, labels\n",
    "\n",
    "\n",
    "features, labels = preprocess_data(df)\n",
    "features.shape, labels.shape"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 数据集的划分与加载\n",
    "\n",
    "我们按照80%，20%的比例将原数据集分为训练集和测试集。\n",
    "  \n",
    "为了保证实验结果的可复现性，我们设置随机数种子 seed=42。\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.model_selection import train_test_split\n",
    "\n",
    "X_train, X_test, y_train, y_test = train_test_split(features, labels, test_size=0.2, random_state=42, stratify=labels)\n",
    "print(f\"Train dataset size: {len(X_train)}\")\n",
    "print(f\"Test dataset size: {len(X_test)}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "我们创建一个`ObesityDataset`类，继承自`torch.utils.data.Dataset`，这样方面后续处理与加载。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import torch\n",
    "from torch.utils.data import Dataset, DataLoader\n",
    "\n",
    "\n",
    "class ObesityDataset(Dataset):\n",
    "    \"\"\"\n",
    "    A custom dataset class for handling obesity data.\n",
    "\n",
    "    Args:\n",
    "        features (numpy.Array): The input features.\n",
    "        labels (numpy.Array): The corresponding labels.\n",
    "\n",
    "    Attributes:\n",
    "        X (torch.Tensor): The input features as a tensor.\n",
    "        y (torch.Tensor): The corresponding labels as a tensor.\n",
    "    \"\"\"\n",
    "\n",
    "    def __init__(self, features, labels):\n",
    "        self.X = torch.tensor(features, dtype=torch.float)\n",
    "        self.y = torch.tensor(labels, dtype=torch.long)\n",
    "\n",
    "    def __len__(self):\n",
    "        return len(self.X)\n",
    "\n",
    "    def __getitem__(self, idx):\n",
    "        return self.X[idx], self.y[idx]\n",
    "\n",
    "\n",
    "batch_size = 64\n",
    "train_dataset = ObesityDataset(X_train, y_train)\n",
    "test_dataset = ObesityDataset(X_test, y_test)\n",
    "train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)\n",
    "test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 构建 MLP 网络\n",
    "\n",
    "我们创建一个多层感知机（MLP）网络，包含一个输入层、任意个隐藏层和一个输出层。其中，输入层的维度为 19，即数据集中的特征数；输出层的维度为 7，即某样本属于各类别的概率值。隐藏层的维度可以自行设置。\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import torch.nn as nn\n",
    "\n",
    "class MLP(nn.Module):\n",
    "    def __init__(self, input_size, hidden_size, output_size):\n",
    "        super(MLP, self).__init__()\n",
    "        # TODO\n",
    "    \n",
    "    def forward(self, x):\n",
    "        # TODO\n",
    "        \n",
    "        return x\n",
    "    \n",
    "input_size = X_train.shape[1]\n",
    "hidden_size = 64 #可自行修改\n",
    "output_size = 7\n",
    "model = MLP(input_size, hidden_size, output_size)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 训练与测试\n",
    "\n",
    "我们将训练一个 epoch 的代码封装在`train_epoch`函数中，其中流程如下：\n",
    "\n",
    "1. 遍历训练集，将数据输入模型，得到预测值；\n",
    "2. 计算预测值与真实值之间的差值，然后计算损失；\n",
    "3. 通过反向传播更新模型参数。\n",
    "4. 返回训练集上的平均损失。\n",
    "\n",
    "然后我们实现了一个`eval_model`函数来评估模型的性能：\n",
    "\n",
    "\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Accumulator:\n",
    "    def __init__(self, n):\n",
    "        self.data = [0.0] * n\n",
    "\n",
    "    def add(self, *args):\n",
    "        self.data = [a + float(b) for a, b in zip(self.data, args)]\n",
    "\n",
    "    def reset(self):\n",
    "        self.data = [0.0] * len(self.data)\n",
    "\n",
    "    def __getitem__(self, idx):\n",
    "        return self.data[idx]\n",
    "\n",
    "\n",
    "def train_epoch(net, device, train_iter, loss_fn, optimizer):\n",
    "    \"\"\"\n",
    "    将 net 模型在 train_iter 上训练一个 epoch, 并返回训练损失.\n",
    "\n",
    "    Args:\n",
    "        net (torch.nn.Module): The neural network model.\n",
    "        device (torch.device): The device to run the training on (e.g., 'cpu', 'cuda').\n",
    "        train_iter (torch.utils.data.DataLoader): The training data iterator.\n",
    "        loss_fn (torch.nn.Module): The loss function.\n",
    "        optimizer (torch.optim.Optimizer): The optimizer.\n",
    "\n",
    "    Returns:\n",
    "        train_loss (float), train_acc (float)\n",
    "    \"\"\"\n",
    "    net.train()\n",
    "    metrics = Accumulator(3)\n",
    "    for X, y in train_iter:\n",
    "        X, y = X.to(device), y.to(device)\n",
    "        # TODO\n",
    "\n",
    "        metrics.add(loss * len(y), accuracy(y_hat, y) * len(y), len(y))\n",
    "    train_loss = metrics[0] / metrics[2]\n",
    "    train_acc = metrics[1] / metrics[2]\n",
    "    return train_loss, train_acc\n",
    "\n",
    "\n",
    "@torch.no_grad()\n",
    "def eval_model(net, device, test_iter, loss_fn):\n",
    "    \"\"\"\n",
    "    评估 net 模型在 data_iter 上的性能，并返回评估损失.\n",
    "\n",
    "    Parameters:\n",
    "    - net (torch.nn.Module): The neural network model to evaluate.\n",
    "    - device (torch.device): The device to run the evaluation on (e.g., 'cpu', 'cuda').\n",
    "    - data_iter (torch.utils.data.DataLoader): The data iterator.\n",
    "    - loss_fn (torch.nn.Module): The loss function used to evaluate the model.\n",
    "\n",
    "    Returns:\n",
    "    - eval_loss (float), eval_acc (float)\n",
    "    \"\"\"\n",
    "    net.eval()\n",
    "    metrics = Accumulator(3)\n",
    "    for X, y in test_iter:\n",
    "        X, y = X.to(device), y.to(device)\n",
    "        # TODO\n",
    "\n",
    "        metrics.add(loss * len(y), accuracy(y_hat, y) * len(y), len(y))\n",
    "    test_loss = metrics[0] / metrics[2]\n",
    "    test_acc = metrics[1] / metrics[2]\n",
    "    return test_loss, test_acc\n",
    "\n",
    "\n",
    "def accuracy(y_hat, y_true):\n",
    "    \"\"\"\n",
    "    计算y_hat和y_true之间的准确率, 注意y_hat为每一类的概率值, 需要转换为与y_true相同的形状\n",
    "\n",
    "    Parameters:\n",
    "    - y_hat (tensor): Predicted labels tensor of shape (batch_size, num_classes).\n",
    "    - y_true (tensor): True labels tensor of shape (batch_size).\n",
    "\n",
    "    Returns:\n",
    "    - acc (float): The accuracy of the predicted labels.\n",
    "    \"\"\"\n",
    "    # TODO\n",
    "    \n",
    "    return acc"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "epochs = 50\n",
    "device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n",
    "model = model.to(device)\n",
    "loss_fn = nn.CrossEntropyLoss()\n",
    "optimizer = torch.optim.Adam(model.parameters(), lr=0.001)\n",
    "train_ls, test_ls, train_acc_ls, test_acc_ls = [], [], [], []\n",
    "\n",
    "for epoch in range(1, epochs + 1):\n",
    "    train_loss, train_acc = train_epoch(model, device, train_loader, loss_fn, optimizer)\n",
    "    test_loss, test_acc = eval_model(model, device, test_loader, loss_fn)\n",
    "    print(\n",
    "        f\"Epoch {epoch}/{epochs} - Train Loss: {train_loss:.6f} - Test Loss: {test_loss:.6f} - Train Acc: {train_acc:.6f} - Test Acc: {test_acc:.6f}\"\n",
    "    )\n",
    "    train_ls.append(train_loss)\n",
    "    test_ls.append(test_loss)\n",
    "    train_acc_ls.append(train_acc)\n",
    "    test_acc_ls.append(test_acc)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "画出模型在训练集和测试集上的 Loss 和 Acc 曲线。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import matplotlib.pyplot as plt\n",
    "\n",
    "%config InlineBackend.figure_format='retina'\n",
    "\n",
    "fig, axs = plt.subplots(nrows=1, ncols=2, figsize=(12, 4))\n",
    "\n",
    "# 绘制第一个子图\n",
    "axs[0].plot(train_ls, label=\"train loss\")\n",
    "\n",
    "axs[0].plot(test_ls, label=\"test loss\")\n",
    "axs[0].set_xlabel(\"Epoch\")\n",
    "axs[0].set_ylabel(\"Loss\")\n",
    "axs[0].legend()\n",
    "\n",
    "# 绘制第二个子图\n",
    "axs[1].plot(train_acc_ls, label=\"train_acc\")\n",
    "axs[1].plot(test_acc_ls, label=\"test_acc\")\n",
    "axs[1].axhline(y=0.9, color='r', linestyle='--')  # 添加 y=0.9 的参考线\n",
    "axs[1].axhline(y=0.85, color='b', linestyle='--')  # 添加 y=0.85 的参考线\n",
    "axs[1].set_xlabel(\"Epoch\")\n",
    "axs[1].set_ylabel(\"Accuracy\")\n",
    "axs[1].legend()\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"Best test accuaracy: \", max(test_acc_ls))\n",
    "print(\"Best train accuaracy: \", max(train_acc_ls))"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.9.13"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
